package com.bugsnag.ohos;

import ohos.aafwk.ability.AbilityPackage;
import ohos.app.Context;
import ohos.app.ElementsCallback;
import ohos.app.IAbilityManager;
import ohos.storageinfomgr.StorageInfoManager;

import com.bugsnag.ohos.annotation.NonNull;
import com.bugsnag.ohos.annotation.VisibleForTesting;
import com.bugsnag.ohos.internal.ImmutableConfig;
import com.bugsnag.ohos.internal.StateObserver;
import com.bugsnag.ohos.util.OhosLog;

import java.io.File;
import java.io.IOException;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.RejectedExecutionException;

import static com.bugsnag.ohos.ContextExtensions.getAbilityManagerFrom;
import static com.bugsnag.ohos.SeverityReason.REASON_HANDLED_EXCEPTION;
import static com.bugsnag.ohos.internal.ImmutableConfig.sanitiseConfiguration;

/**
 * A Bugsnag Client instance allows you to use Bugsnag in your ohos app.
 * Typically you'd instead use the static access provided in the Bugsnag class.
 * <p/>
 * Example usage:
 * <p/>
 * Client client = new Client(this, "your-api-key");
 * client.notify(new RuntimeException("something broke!"));
 *
 * @see Bugsnag
 */
@SuppressWarnings({"checkstyle:JavadocTagContinuationIndentation", "ConstantConditions"})
public class Client implements MetadataAware, CallbackAware, UserAware {
    public final Notifier notifier = new Notifier();
    private static final String TAG = "Client";
    protected final EventStore eventStore;
    final BreadcrumbState breadcrumbState;
    final SessionTracker sessionTracker;
    final Connectivity connectivity;
    public final Logger logger;
    final DeliveryDelegate deliveryDelegate;
    final ClientObservable clientObservable;
    final LastRunInfo lastRunInfo;
    final LastRunInfoStore lastRunInfoStore;
    final LaunchCrashTracker launchCrashTracker;
    final BackgroundTaskService bgTaskService = new BackgroundTaskService();
    public ImmutableConfig immutableConfig;
    MetadataState metadataState;
    Context appContext;
    DeviceDataCollector deviceDataCollector;
    AppDataCollector appDataCollector;
    private ContextState contextState;
    private CallbackState callbackState;
    private UserState userState;
    private final SessionStore sessionStore;
    private final SystemBroadcastReceiver systemBroadcastReceiver;
    private final AbilityBreadcrumbCollector abilityBreadcrumbCollector;
    private final SessionLifecycleCallback sessionLifecycleCallback;
    private final StorageInfoManager storageManager;
    private PluginClient pluginClient;
    private final ExceptionHandler exceptionHandler;

    /**
     * Initialize a Bugsnag client
     *
     * @param context an ohos context, usually <code>this</code>
     */
    public Client(Context context) {
        this(context, Configuration.load(context));
    }

    /**
     * Initialize a Bugsnag client
     *
     * @param context an ohos context, usually <code>this</code>
     * @param apiKey your Bugsnag API key from your Bugsnag dashboard
     */
    public Client(Context context, String apiKey) {
        this(context, Configuration.load(context, apiKey));
    }

    /**
     * Initialize a Bugsnag client
     *
     * @param context an Ohos context, usually <code>this</code>
     * @param configuration a configuration for the Client
     */
    public Client(Context context, final Configuration configuration) {
        OhosLog.i(TAG, "init start... %s", context);
        if (context instanceof AbilityPackage) {
            appContext = context;
        } else {
            appContext = context.getApplicationContext();
        }
        OhosLog.i(TAG, "init start... appContext: %s", appContext);
        connectivity = new ConnectivityCompat(appContext, new NetworkChangeCallback() {
            @Override
            public void invoke(boolean hasConnection, String networkState) {
                Map<String, Object> data = new HashMap<>();
                data.put("hasConnection", hasConnection);
                data.put("networkState", networkState);
                leaveAutoBreadcrumb("Connectivity changed", BreadcrumbType.STATE, data);
                if (hasConnection) {
                    eventStore.flushAsync();
                    sessionTracker.flushAsync();
                }
            }
        });

        immutableConfig = sanitiseConfiguration(appContext, configuration, connectivity);
        logger = immutableConfig.getLogger();
        warnIfNotAppContext(context);
        clientObservable = new ClientObservable();
        callbackState = configuration.impl.callbackState.copy();
        int maxBreadcrumbs = immutableConfig.getMaxBreadcrumbs();
        breadcrumbState = new BreadcrumbState(maxBreadcrumbs, callbackState, logger);
        storageManager = ContextExtensions.getStorageManagerFrom(appContext);
        contextState = new ContextState();
        contextState.setContext(configuration.getContext());
        sessionStore = new SessionStore(immutableConfig, logger, null);
        sessionTracker = new SessionTracker(immutableConfig, callbackState, this,
            sessionStore, logger, bgTaskService);
        metadataState = copyMetadataState(configuration);
        IAbilityManager am = getAbilityManagerFrom(appContext);
        launchCrashTracker = new LaunchCrashTracker(immutableConfig);
        appDataCollector = new AppDataCollector(appContext, appContext.getBundleManager(),
            immutableConfig, sessionTracker, am, launchCrashTracker, logger);
        SharedPrefMigrator sharedPrefMigrator = new SharedPrefMigrator(appContext);
        DeviceIdStore deviceIdStore = new DeviceIdStore(appContext, sharedPrefMigrator, logger);
        String deviceId = deviceIdStore.loadDeviceId();
        UserStore userStore = new UserStore(immutableConfig, deviceId, sharedPrefMigrator, logger);
        userState = userStore.load(configuration.getUser());
        sharedPrefMigrator.deleteLegacyPrefs();
        DeviceBuildInfo info = DeviceBuildInfo.defaultInfo();
        File getDataDirectory = appContext.getDataDir();
        deviceDataCollector = new DeviceDataCollector(connectivity, appContext, deviceId, info, getDataDirectory,
            new RootDetector(logger), bgTaskService, logger);
        if (appContext instanceof AbilityPackage) {
            AbilityPackage application = (AbilityPackage) appContext;
            sessionLifecycleCallback = new SessionLifecycleCallback(sessionTracker);
            OhosLog.i(TAG, "init start... registerCallbacks : %s", sessionLifecycleCallback);
            application.registerCallbacks(sessionLifecycleCallback, new ElementsCallback() {
                @Override
                public void onMemoryLevel(int i) {
                    clientObservable.postMemoryTrimEvent(true);
                }

                @Override
                public void onConfigurationUpdated(ohos.global.configuration.Configuration configuration) {
                    Map<String, Object> data = new HashMap<>();
                    String oldOrientation = "horizontal";
                    String newOrientation = "vertical";
                    if (configuration.direction == ohos.global.configuration.Configuration.DIRECTION_VERTICAL) {
                        newOrientation = "vertical";
                        oldOrientation = "horizontal";
                    }
                    data.put("from", oldOrientation);
                    data.put("to", newOrientation);
                    leaveAutoBreadcrumb("Orientation changed", BreadcrumbType.STATE, data);
                    clientObservable.postOrientationChange(newOrientation);
                }
            });

            if (!immutableConfig.shouldDiscardBreadcrumb(BreadcrumbType.STATE)) {
                this.abilityBreadcrumbCollector = new AbilityBreadcrumbCollector(
                    (message, method) -> leaveBreadcrumb(message, method,
                        BreadcrumbType.STATE));
                OhosLog.i(TAG, "init start... registerCallbacks : %s", abilityBreadcrumbCollector);
                application.registerCallbacks(abilityBreadcrumbCollector, new ElementsCallback() {
                    @Override
                    public void onMemoryLevel(int i) {
                    }

                    @Override
                    public void onConfigurationUpdated(ohos.global.configuration.Configuration configuration) {
                    }
                });
            } else {
                this.abilityBreadcrumbCollector = null;
            }
        } else {
            this.abilityBreadcrumbCollector = null;
            this.sessionLifecycleCallback = null;
        }

        InternalReportDelegate delegate = new InternalReportDelegate(appContext, logger,
            immutableConfig, storageManager, appDataCollector, deviceDataCollector,
            sessionTracker, notifier, bgTaskService);
        eventStore = new EventStore(immutableConfig, logger, notifier, bgTaskService, delegate);

        deliveryDelegate = new DeliveryDelegate(logger, eventStore,
            immutableConfig, breadcrumbState, notifier, bgTaskService);
        exceptionHandler = new ExceptionHandler(this, logger);
        if (immutableConfig.getEnabledErrorTypes().getUnhandledExceptions()) {
            exceptionHandler.install();
        }
        lastRunInfoStore = new LastRunInfoStore(immutableConfig);
        lastRunInfo = loadLastRunInfo();
        loadPlugins(configuration);
        eventStore.flushOnLaunch();
        eventStore.flushAsync();
        sessionTracker.flushAsync();
        systemBroadcastReceiver = new SystemBroadcastReceiver(this, logger);
        registerComponentCallbacks();
        registerListenersInBackground();
        Map<String, Object> data = Collections.emptyMap();
        leaveAutoBreadcrumb("Bugsnag loaded", BreadcrumbType.STATE, data);
        logger.d("Bugsnag loaded");
        OhosLog.i(TAG, "init end...");
    }

    @VisibleForTesting
    Client(
        ImmutableConfig immutableConfig,
        MetadataState metadataState,
        ContextState contextState,
        CallbackState callbackState,
        UserState userState,
        ClientObservable clientObservable,
        Context appContext,
        DeviceDataCollector deviceDataCollector,
        AppDataCollector appDataCollector,
        BreadcrumbState breadcrumbState,
        EventStore eventStore,
        SessionStore sessionStore,
        SystemBroadcastReceiver systemBroadcastReceiver,
        SessionTracker sessionTracker,
        AbilityBreadcrumbCollector abilityBreadcrumbCollector,
        SessionLifecycleCallback sessionLifecycleCallback,
        Connectivity connectivity,
        StorageInfoManager storageManager,
        Logger logger,
        DeliveryDelegate deliveryDelegate,
        LastRunInfoStore lastRunInfoStore,
        LaunchCrashTracker launchCrashTracker,
        ExceptionHandler exceptionHandler
    ) {
        this.immutableConfig = immutableConfig;
        this.metadataState = metadataState;
        this.contextState = contextState;
        this.callbackState = callbackState;
        this.userState = userState;
        this.clientObservable = clientObservable;
        this.appContext = appContext;
        this.deviceDataCollector = deviceDataCollector;
        this.appDataCollector = appDataCollector;
        this.breadcrumbState = breadcrumbState;
        this.eventStore = eventStore;
        this.sessionStore = sessionStore;
        this.systemBroadcastReceiver = systemBroadcastReceiver;
        this.sessionTracker = sessionTracker;
        this.abilityBreadcrumbCollector = abilityBreadcrumbCollector;
        this.sessionLifecycleCallback = sessionLifecycleCallback;
        this.connectivity = connectivity;
        this.storageManager = storageManager;
        this.logger = logger;
        this.deliveryDelegate = deliveryDelegate;
        this.lastRunInfoStore = lastRunInfoStore;
        this.launchCrashTracker = launchCrashTracker;
        this.lastRunInfo = null;
        this.exceptionHandler = exceptionHandler;
    }

    /**
     * Registers listeners for system events in the background. This offloads work from the main
     * thread that collects useful information from callbacks, but that don't need to be done
     * immediately on client construction.
     */
    void registerListenersInBackground() {
        try {
            bgTaskService.submitTask(TaskType.DEFAULT, () -> {
                connectivity.registerForNetworkChanges();
                systemBroadcastReceiver.register(systemBroadcastReceiver, logger);
            });
        } catch (RejectedExecutionException ex) {
            logger.w("Failed to register for system events", ex);
        }
    }

    private LastRunInfo loadLastRunInfo() {
        LastRunInfo info = lastRunInfoStore.load();
        LastRunInfo currentRunInfo = new LastRunInfo(0, false, false);
        persistRunInfo(currentRunInfo);
        return info;
    }

    /**
     * Load information about the last run, and reset the persisted information to the defaults.
     */
    private void persistRunInfo(final LastRunInfo runInfo) {
        try {
            bgTaskService.submitTask(TaskType.IO, new Runnable() {
                @Override
                public void run() {
                    lastRunInfoStore.persist(runInfo);
                }
            });
        } catch (RejectedExecutionException exc) {
            logger.w("Failed to persist last run info", exc);
        }
    }

    private void loadPlugins(Configuration configuration) {
        NativeInterface.setClient(this);
        Set<Plugin> userPlugins = configuration.getPlugins();
        pluginClient = new PluginClient(userPlugins, immutableConfig, logger);
        pluginClient.loadPlugins(this);
    }

    private void logNull(String property) {
        logger.e("Invalid null value supplied to client." + property + ", ignoring");
    }

    private MetadataState copyMetadataState(Configuration configuration) {
        // performs deep copy of metadata to preserve immutability of Configuration interface
        Metadata orig = configuration.impl.getMetadataState().getMetadata();
        Metadata copy = orig.copy();
        return configuration.impl.getMetadataState().copy(copy);
    }

    private void registerComponentCallbacks() {
        ((AbilityPackage) appContext).registerCallbacks(abilityBreadcrumbCollector,
            new ClientComponentCallbacks(appContext, deviceDataCollector, isLowMemory
                -> clientObservable.postMemoryTrimEvent(isLowMemory), (oldOrientation, newOrientation) -> {
                Map<String, Object> data = new HashMap<>();
                data.put("from", oldOrientation);
                data.put("to", newOrientation);
                leaveAutoBreadcrumb("Orientation changed", BreadcrumbType.STATE, data);
                clientObservable.postOrientationChange(newOrientation);
            }));
    }

    public void setupNdkPlugin(){
        String lastRunInfoPath = "";
        try {
            lastRunInfoPath = lastRunInfoStore.getFile().getCanonicalPath();
        } catch (IOException e) {
            e.printStackTrace();
        }
        int crashes = (lastRunInfo != null) ? lastRunInfo.getConsecutiveLaunchCrashes() : 0;
        clientObservable.postNdkInstall(immutableConfig, lastRunInfoPath, crashes);
        syncInitialState();
        clientObservable.postNdkDeliverPending();
    }

    public void addObserver(StateObserver observer) {
        metadataState.addObserver(observer);
        breadcrumbState.addObserver(observer);
        sessionTracker.addObserver(observer);
        clientObservable.addObserver(observer);
        userState.addObserver(observer);
        contextState.addObserver(observer);
        deliveryDelegate.addObserver(observer);
        launchCrashTracker.addObserver(observer);
    }

    void removeObserver(StateObserver observer) {
        metadataState.removeObserver(observer);
        breadcrumbState.removeObserver(observer);
        sessionTracker.removeObserver(observer);
        clientObservable.removeObserver(observer);
        userState.removeObserver(observer);
        contextState.removeObserver(observer);
        deliveryDelegate.removeObserver(observer);
        launchCrashTracker.removeObserver(observer);
    }

    /**
     * Sends initial state values for Metadata/User/Context to any registered observers.
     */
    public void syncInitialState() {
        metadataState.emitObservableEvent();
        contextState.emitObservableEvent();
        userState.emitObservableEvent();
    }

    /**
     * Starts tracking a new session. You should disable automatic session tracking via
     * {@link Configuration#setAutoTrackSessions(boolean)} if you call this method.
     * <p/>
     * You should call this at the appropriate time in your application when you wish to start a
     * session. Any subsequent errors which occur in your application will still be reported to
     * Bugsnag but will not count towards your application's
     * <a href="https://docs.bugsnag.com/product/releases/releases-dashboard/#stability-score">
     * stability score</a>. This will start a new session even if there is already an existing
     * session; you should call {@link #resumeSession()} if you only want to start a session
     * when one doesn't already exist.
     *
     * @see #resumeSession()
     * @see #pauseSession()
     * @see Configuration#setAutoTrackSessions(boolean)
     */
    public void startSession() {
        sessionTracker.startSession(false);
    }

    /**
     * Pauses tracking of a session. You should disable automatic session tracking via
     * {@link Configuration#setAutoTrackSessions(boolean)} if you call this method.
     * <p/>
     * You should call this at the appropriate time in your application when you wish to pause a
     * session. Any subsequent errors which occur in your application will still be reported to
     * Bugsnag but will not count towards your application's
     * <a href="https://docs.bugsnag.com/product/releases/releases-dashboard/#stability-score">
     * stability score</a>. This can be advantageous if, for example, you do not wish the
     * stability score to include crashes in a background service.
     *
     * @see #startSession()
     * @see #resumeSession()
     * @see Configuration#setAutoTrackSessions(boolean)
     */
    public void pauseSession() {
        sessionTracker.pauseSession();
    }

    /**
     * Resumes a session which has previously been paused, or starts a new session if none exists.
     * If a session has already been resumed or started and has not been paused, calling this
     * method will have no effect. You should disable automatic session tracking via
     * {@link Configuration#setAutoTrackSessions(boolean)} if you call this method.
     * <p/>
     * It's important to note that sessions are stored in memory for the lifetime of the
     * application process and are not persisted on disk. Therefore calling this method on app
     * startup would start a new session, rather than continuing any previous session.
     * <p/>
     * You should call this at the appropriate time in your application when you wish to resume
     * a previously started session. Any subsequent errors which occur in your application will
     * still be reported to Bugsnag but will not count towards your application's
     * <a href="https://docs.bugsnag.com/product/releases/releases-dashboard/#stability-score">
     * stability score</a>.
     *
     * @return true if a previous session was resumed, false if a new session was started.
     * @see #startSession()
     * @see #pauseSession()
     * @see Configuration#setAutoTrackSessions(boolean)
     */
    public boolean resumeSession() {
        return sessionTracker.resumeSession();
    }

    /**
     * Bugsnag uses the concept of "contexts" to help display and group your errors. Contexts
     * represent what was happening in your application at the time an error occurs.
     * <p>
     * In an ohos app the "context" is automatically set as the foreground Activity.
     * If you would like to set this value manually, you should alter this property.
     *
     * @return context
     */
    public String getContext() {
        return contextState.getContext();
    }

    /**
     * Bugsnag uses the concept of "contexts" to help display and group your errors. Contexts
     * represent what was happening in your application at the time an error occurs.
     * <p>
     * In an ohos app the "context" is automatically set as the foreground Activity.
     * If you would like to set this value manually, you should alter this property.
     *
     * @param context
     */
    public void setContext(String context) {
        contextState.setContext(context);
    }

    /**
     * Sets the user associated with the event.
     *
     * @param id
     * @param email
     * @param name
     */
    @Override
    public void setUser(String id, String email, String name) {
        userState.setUser(new User(id, email, name));
    }

    /**
     * Returns the currently set User information.
     *
     * @return user
     */
    @NonNull
    @Override
    public User getUser() {
        return userState.getUser();
    }

    /**
     * Add a "on error" callback, to execute code at the point where an error report is
     * captured in Bugsnag.
     * <p>
     * You can use this to add or modify information attached to an Event
     * before it is sent to your dashboard. You can also return
     * <code>false</code> from any callback to prevent delivery. "on error"
     * callbacks do not run before reports generated in the event
     * of immediate app termination from crashes in C/C++ code.
     * <p>
     * For example:
     * <p>
     * Bugsnag.addOnError(new OnErrorCallback() {
     * public boolean run(Event event) {
     * event.setSeverity(Severity.INFO);
     * return true;
     * }
     * })
     *
     * @param onError a callback to run before sending errors to Bugsnag
     * @see OnErrorCallback
     */
    @Override
    public void addOnError(OnErrorCallback onError) {
        if (onError != null) {
            callbackState.addOnError(onError);
        } else {
            logNull("addOnError");
        }
    }

    /**
     * Removes a previously added "on error" callback
     *
     * @param onError the callback to remove
     */
    @Override
    public void removeOnError(OnErrorCallback onError) {
        if (onError != null) {
            callbackState.removeOnError(onError);
        } else {
            logNull("removeOnError");
        }
    }

    /**
     * Add an "on breadcrumb" callback, to execute code before every
     * breadcrumb captured by Bugsnag.
     * <p>
     * You can use this to modify breadcrumbs before they are stored by Bugsnag.
     * You can also return <code>false</code> from any callback to ignore a breadcrumb.
     * <p>
     * For example:
     * <p>
     * Bugsnag.onBreadcrumb(new OnBreadcrumbCallback() {
     * public boolean run(Breadcrumb breadcrumb) {
     * return false; // ignore the breadcrumb
     * }
     * })
     *
     * @param onBreadcrumb a callback to run before a breadcrumb is captured
     * @see OnBreadcrumbCallback
     */
    @Override
    public void addOnBreadcrumb(OnBreadcrumbCallback onBreadcrumb) {
        if (onBreadcrumb != null) {
            callbackState.addOnBreadcrumb(onBreadcrumb);
        } else {
            logNull("addOnBreadcrumb");
        }
    }

    /**
     * Removes a previously added "on breadcrumb" callback
     *
     * @param onBreadcrumb the callback to remove
     */
    @Override
    public void removeOnBreadcrumb(OnBreadcrumbCallback onBreadcrumb) {
        if (onBreadcrumb != null) {
            callbackState.removeOnBreadcrumb(onBreadcrumb);
        } else {
            logNull("removeOnBreadcrumb");
        }
    }

    /**
     * Add an "on session" callback, to execute code before every
     * session captured by Bugsnag.
     * <p>
     * You can use this to modify sessions before they are stored by Bugsnag.
     * You can also return <code>false</code> from any callback to ignore a session.
     * <p>
     * For example:
     * <p>
     * Bugsnag.onSession(new OnSessionCallback() {
     * public boolean run(Session session) {
     * return false; // ignore the session
     * }
     * })
     *
     * @param onSession a callback to run before a session is captured
     * @see OnSessionCallback
     */
    @Override
    public void addOnSession(OnSessionCallback onSession) {
        if (onSession != null) {
            callbackState.addOnSession(onSession);
        } else {
            logNull("addOnSession");
        }
    }

    /**
     * Removes a previously added "on session" callback
     *
     * @param onSession the callback to remove
     */
    @Override
    public void removeOnSession(OnSessionCallback onSession) {
        if (onSession != null) {
            callbackState.removeOnSession(onSession);
        } else {
            logNull("removeOnSession");
        }
    }

    /**
     * Notify Bugsnag of a handled exception
     *
     * @param exception the exception to send to Bugsnag
     */
    public void notify(Throwable exception) {
        notify(exception, null);
    }

    /**
     * Notify Bugsnag of a handled exception
     *
     * @param exc the exception to send to Bugsnag
     * @param onError callback invoked on the generated error report for
     * additional modification
     */
    public void notify(Throwable exc, OnErrorCallback onError) {
        logger.d("notify :%s", exc);
        if (exc != null) {
            if (immutableConfig.shouldDiscardError(exc)) {
                return;
            }
            SeverityReason severityReason = SeverityReason.newInstance(REASON_HANDLED_EXCEPTION);
            Metadata metadata = metadataState.getMetadata();
            logger.d("notify event...");
            Event event = new Event(exc, immutableConfig, severityReason, metadata, logger);
            logger.d("notify event2...");
            populateAndNotifyOhosEvent(event, onError);
        } else {
            logNull("notify");
        }
    }

    /**
     * Caches an error then attempts to notify.
     * <p>
     * Should only ever be called from the {@link ExceptionHandler}.
     */
    void notifyUnhandledException(Throwable exc, Metadata metadata,
                                  @SeverityReason.SeverityReasonType String severityReason,
                                  String attributeValue) {
        logger.d("notifyUnhandledException start...");
        final SeverityReason handledState = SeverityReason.newInstance(severityReason, Severity.ERROR, attributeValue);
        logger.d("notifyUnhandledException start1...");
        OhosLog.d(TAG, " metadataState : %s", metadataState);
        Metadata data = Metadata.merge(metadataState.getMetadata(), metadata);
        OhosLog.d(TAG, " Metadata data: %s", data);
        logger.d("notifyUnhandledException start2...");
        Event event = new Event(exc, immutableConfig, handledState, data, logger);
        logger.d("notifyUnhandledException start3...");
        populateAndNotifyOhosEvent(event, null);
        logger.d("notifyUnhandledException start4...");
        int consecutiveLaunchCrashes = lastRunInfo == null ? 0
            : lastRunInfo.getConsecutiveLaunchCrashes();
        boolean launching = launchCrashTracker.isLaunching();
        if (launching) {
            consecutiveLaunchCrashes += 1;
        }
        LastRunInfo runInfo = new LastRunInfo(consecutiveLaunchCrashes, true, launching);
        persistRunInfo(runInfo);
        bgTaskService.shutdown();
        logger.d("notifyUnhandledException end...");
    }

    void populateAndNotifyOhosEvent(Event event, OnErrorCallback onError) {
        logger.d("populateAndNotifyOhosEvent start");
        event.setDevice(deviceDataCollector.generateDeviceWithState(new Date().getTime()));
        event.addMetadata("device", deviceDataCollector.getDeviceMetadata());
        event.setApp(appDataCollector.generateAppWithState());
        event.addMetadata("app", appDataCollector.getAppDataMetadata());
        logger.d("populateAndNotifyOhosEvent start1");
        event.setBreadcrumbs(breadcrumbState.copy());
        logger.d("populateAndNotifyOhosEvent start2");
        User user = userState.getUser();
        event.setUser(user.getId(), user.getEmail(), user.getName());
        if (Intrinsics.isEmpty(event.getContext())) {
            String context = contextState.getContext();
            event.setContext(context != null ? context : appDataCollector.getActiveScreenClass());
        }
        notifyInternal(event, onError);
        logger.d("populateAndNotifyOhosEvent end");
    }

    public void notifyInternal(Event event, OnErrorCallback onError) {
        String type = event.getImpl().getSeverityReasonType();
        logger.d("Client#notifyInternal() - event captured by Client, type=" + type);
        Set<String> redactedKeys = metadataState.getMetadata().getRedactedKeys();
        Metadata eventMetadata = event.getImpl().getMetadata();
        eventMetadata.setRedactedKeys(redactedKeys);
        Session currentSession = sessionTracker.getCurrentSession();
        if (currentSession != null
            && (immutableConfig.getAutoTrackSessions() || !currentSession.isAutoCaptured())) {
            event.setSession(currentSession);
        }
        if (!callbackState.runOnErrorTasks(event, logger)
            || (onError != null && !onError.onError(event))) {
            logger.d("Skipping notification - onError task returned false");
            return;
        }
        deliveryDelegate.deliver(event);
        logger.d("notifyInternal end");
    }

    /**
     * Returns the current buffer of breadcrumbs that will be sent with captured events. This
     * ordered list represents the most recent breadcrumbs to be captured up to the limit
     * set in {@link Configuration#getMaxBreadcrumbs()}.
     * <p>
     * The returned collection is readonly and mutating the list will cause no effect on the
     * Client's state. If you wish to alter the breadcrumbs collected by the Client then you should
     * use {@link Configuration#setEnabledBreadcrumbTypes(Set)} and
     * {@link Configuration#addOnBreadcrumb(OnBreadcrumbCallback)} instead.
     *
     * @return a list of collected breadcrumbs
     */
    @NonNull
    public List<Breadcrumb> getBreadcrumbs() {
        return breadcrumbState.copy();
    }

    @NonNull
    public AppDataCollector getAppDataCollector() {
        return appDataCollector;
    }

    @NonNull
    public DeviceDataCollector getDeviceDataCollector() {
        return deviceDataCollector;
    }

    /**
     * Adds a map of multiple metadata key-value pairs to the specified section.
     */
    @Override
    public void addMetadata(String section, Map<String, Object> value) {
        if (section != null && value != null) {
            metadataState.addMetadata(section, value);
        } else {
            logNull("addMetadata");
        }
    }

    /**
     * Adds the specified key and value in the specified section. The value can be of
     * any primitive type or a collection such as a map, set or array.
     */
    @Override
    public void addMetadata(String section, String key, Object value) {
        if (section != null && key != null) {
            metadataState.addMetadata(section, key, value);
        } else {
            logNull("addMetadata");
        }
    }

    /**
     * Removes all the data from the specified section.
     */
    @Override
    public void clearMetadata(String section) {
        if (section != null) {
            metadataState.clearMetadata(section);
        } else {
            logNull("clearMetadata");
        }
    }

    /**
     * Removes data with the specified key from the specified section.
     */
    @Override
    public void clearMetadata(String section, String key) {
        if (section != null && key != null) {
            metadataState.clearMetadata(section, key);
        } else {
            logNull("clearMetadata");
        }
    }

    /**
     * Returns a map of data in the specified section.
     *
     * @return meta data
     */
    @Override
    public Map<String, Object> getMetadata(String section) {
        if (section != null) {
            return metadataState.getMetadata(section);
        } else {
            logNull("getMetadata");
            return null;
        }
    }

    /**
     * Returns the value of the specified key in the specified section.
     */
    @Override

    public Object getMetadata(String section, String key) {
        if (section != null && key != null) {
            return metadataState.getMetadata(section, key);
        } else {
            logNull("getMetadata");
            return null;
        }
    }

    // cast map to retain original signature until next major version bump, as this
    // method signature is used by Unity/React native
    @SuppressWarnings({"unchecked", "rawtypes"})
    public Map<String, Object> getMetadata() {
        return (Map) metadataState.getMetadata().toMap();
    }

    /**
     * Leave a "breadcrumb" log message, representing an action that occurred
     * in your app, to aid with debugging.
     *
     * @param message the log message to leave
     */
    public void leaveBreadcrumb(String message) {
        if (message != null) {
            breadcrumbState.add(new Breadcrumb(message, logger));
        } else {
            logNull("leaveBreadcrumb");
        }
    }

    /**
     * Leave a "breadcrumb" log message representing an action or event which
     * occurred in your app, to aid with debugging
     *
     * @param message A short label
     * @param metadata Additional diagnostic information about the app environment
     * @param type A category for the breadcrumb
     */
    public void leaveBreadcrumb(String message,
                                Map<String, Object> metadata,
                                BreadcrumbType type) {
        if (message != null && type != null && metadata != null) {
            breadcrumbState.add(new Breadcrumb(message, type, metadata, new Date(), logger));
        } else {
            logNull("leaveBreadcrumb");
        }
    }

    /**
     * Intended for internal use only - leaves a breadcrumb if the type is enabled for automatic
     * breadcrumbs.
     *
     * @param message A short label
     * @param type A category for the breadcrumb
     * @param metadata Additional diagnostic information about the app environment
     */
    void leaveAutoBreadcrumb(String message,
                             BreadcrumbType type,
                             Map<String, Object> metadata) {
        if (!immutableConfig.shouldDiscardBreadcrumb(type)) {
            breadcrumbState.add(new Breadcrumb(message, type, metadata, new Date(), logger));
        }
    }

    /**
     * Retrieves information about the last launch of the application, if it has been run before.
     * <p>
     * For example, this allows checking whether the app crashed on its last launch, which could
     * be used to perform conditional behaviour to recover from crashes, such as clearing the
     * app data cache.
     *
     * @return LastRunInfo
     */
    public LastRunInfo getLastRunInfo() {
        return lastRunInfo;
    }

    /**
     * Informs Bugsnag that the application has finished launching. Once this has been called
     * will always be false in any new error reports,
     * and synchronous delivery will not be attempted on the next launch for any fatal crashes.
     * <p>
     * By default this method will be called after Bugsnag is initialized when
     * {@link Configuration#getLaunchDurationMillis()} has elapsed. Invoking this method manually
     * has precedence over the value supplied via the launchDurationMillis configuration option.
     */
    public void markLaunchCompleted() {
        launchCrashTracker.markLaunchCompleted();
    }

    SessionTracker getSessionTracker() {
        return sessionTracker;
    }

    EventStore getEventStore() {
        return eventStore;
    }

    /**
     * Finalize by removing the receiver
     *
     * @throws Throwable if something goes wrong
     */
    @SuppressWarnings("checkstyle:NoFinalizer")
    protected void finalize() throws Throwable {
        if (systemBroadcastReceiver != null) {
            try {
                systemBroadcastReceiver.unregister();
            } catch (IllegalArgumentException exception) {
                logger.w("Receiver not registered");
            }
        }
        super.finalize();
    }

    private void warnIfNotAppContext(Context context) {
        if (!(context instanceof AbilityPackage)) {
            logger.w("Warning - Non-Application context detected! Please ensure that you are "
                + "initializing Bugsnag from a custom Application class.");
        }
    }

    public ImmutableConfig getConfig() {
        return immutableConfig;
    }

    void setBinaryArch(String binaryArch) {
        getAppDataCollector().setBinaryArch(binaryArch);
    }

    Context getAppContext() {
        return appContext;
    }

    /**
     * Intended for internal use only - sets the code bundle id for React Native
     */

    String getCodeBundleId() {
        return appDataCollector.getCodeBundleId();
    }

    /**
     * Intended for internal use only - sets the code bundle id for React Native
     */
    public void setCodeBundleId(String codeBundleId) {
        appDataCollector.setCodeBundleId(codeBundleId);
    }

    public void addRuntimeVersionInfo(String key, String value) {
        deviceDataCollector.addRuntimeVersionInfo(key, value);
    }

    @VisibleForTesting
    void close() {
        connectivity.unregisterForNetworkChanges();
        bgTaskService.shutdown();
    }

    public Logger getLogger() {
        return logger;
    }

    /**
     * Retrieves an instantiated plugin of the given type, or null if none has been created
     */
    @SuppressWarnings("rawtypes")
    Plugin getPlugin(Class clz) {
        return pluginClient.findPlugin(clz);
    }

    Notifier getNotifier() {
        return notifier;
    }

    MetadataState getMetadataState() {
        return metadataState;
    }

    void setAutoNotify(boolean autoNotify) {
        pluginClient.setAutoNotify(this, autoNotify);

        if (autoNotify) {
            exceptionHandler.install();
        } else {
            exceptionHandler.uninstall();
        }
    }

    void setAutoDetectAnrs(boolean autoDetectAnrs) {
        pluginClient.setAutoDetectAnrs(this, autoDetectAnrs);
    }
}
